package anonymouschat.service;

import java.io.IOException;
import java.io.Serializable;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;

import anonymouschat.client.PublicIdentity;
import anonymouschat.exceptions.DecryptionException;
import anonymouschat.exceptions.SendMessageToPublishedUserException;
import anonymouschat.exceptions.ServerToServerConnectionException;
import anonymouschat.messages.AESkeyMessage;
import anonymouschat.messages.EncryptedMessageWrapperMessage;
import anonymouschat.messages.KeyResponse;
import anonymouschat.messages.Message;
import anonymouschat.messages.ServerAnnounceMessage;
import anonymouschat.messages.clientrequests.ClientRequest;
import anonymouschat.messages.clientrequests.KeyRequest;
import anonymouschat.messages.clientrequests.UserLoginRequest;
import anonymouschat.messages.clientrequests.UserLogoutRequest;
import anonymouschat.messages.securitymessages.RSAEncryptedMessage;
import anonymouschat.security.SecurityUtils;
import anonymouschat.utils.Constants;
import anonymouschat.utils.ListOfServers;
import anonymouschat.utils.LocalFileOps;
import anonymouschat.utils.Print;

/**
 * Connection coming into a server (send messages TO a server with an instance
 * of this class)
 * 
 */
public class InConnectionThread extends Thread {

	/**
	 * the connection this thread will send and receive messages on
	 */
	private Connection connection = null;

	/**
	 * The servername running this thread
	 */
	private String serverName;

	/**
	 * a handle to the server object owning this thread
	 */
	private Server server = null;

	/**
	 * A list of all servers authorized and available
	 */
	private ListOfServers availableServers = new ListOfServers();

	/**
	 * A pseudo-id to give to another server to keep track of sessions on the
	 * same connection
	 */
	private Long outboundConnectionNumber;

	/**
	 * A thread to handle incoming messages on existing connections
	 * 
	 * @param newConnection
	 *            the connection to monitor
	 * @param server
	 *            a handle to the server owning this thread
	 */
	InConnectionThread(Connection newConnection, Server server) {
		this.connection = newConnection;
		this.server = server;
		this.serverName = server.getServerName();
	}

	/**
	 * Listens to socket for new messages and handles them
	 */
	@Override
	public void run() {
		Print.debug("Thread started to handle connection "
				+ connection.getInfo() + ". . .");

		Object message = null;
		// As long as the connection is not closed, wait for a new message
		while (connection.isOpen()) {
			// Read in the next message to process
			try {
				message = (Object) connection.getInputStream().readObject();
			} catch (IOException e) {
				e.printStackTrace();
			} catch (ClassNotFoundException e) {
				e.printStackTrace();
			}
			Print.debug("Received a message on connection "
					+ connection.getInfo() + ": " + message.toString());

			handleMessage(message);
		}
	}

	/**
	 * check if a message has encryption and remove it, then handle it
	 * 
	 * @param message
	 *            the message received
	 * @return
	 */
	private void handleMessage(Object message) {

		/**
		 * A pseudo-id provided by the remote end to keep track of sessions
		 */
		Long incomingConnectionNumber = null;

		Object unwrappedMessage = null;
		// check if the message needs to be unwrapped
		if (message instanceof EncryptedMessageWrapperMessage) {
			EncryptedMessageWrapperMessage encMsg = (EncryptedMessageWrapperMessage) message;

			// check if an incoming connection number was provided
			if (encMsg.getConnectionNumber() != null) {
				incomingConnectionNumber = encMsg.getConnectionNumber();
				// (upstream) message is going client -> publication server
				if (encMsg.isGoingUpstream()) {
					handleUpstreamMessage(encMsg, incomingConnectionNumber);
				} else {
					handleDownstreamMessage(encMsg, incomingConnectionNumber);
					return;
				}
			}

			// remove the outer encryption layer
			try {
				unwrappedMessage = server.serverIdentity
						.unwrapEncryptionLayer(encMsg);
			} catch (DecryptionException e) {
				Print.error(
						"Unable to unwrap encryption layer on message recieved",
						e, server);
				LocalFileOps
						.appendAuditFile("@Server "
								+ this.serverName
								+ ", Failed to decrypt, and remove a layer of encryption.");
			}

		} else {
			// message is not an encrypted message wrapper message, and needs to
			// be handled directly
			unwrappedMessage = message;

		}

		handleUnwrappedMessage(unwrappedMessage, incomingConnectionNumber);
	}

	/**
	 * handles an inner message after a layer of encryption is removed
	 * 
	 * @param unwrappedMessage
	 *            an unwrapped (inner) message to be handled
	 * @param incomingConnectionNumber
	 *            the connection number the outer message was address to
	 */
	private void handleUnwrappedMessage(Object unwrappedMessage,
			Long incomingConnectionNumber) {
		// handle the inner message
		if (unwrappedMessage instanceof RSAEncryptedMessage) {
			handleRSAEncryptedMessage((RSAEncryptedMessage) unwrappedMessage);
		} else if (unwrappedMessage instanceof ClientRequest) {
			handleClientRequest((ClientRequest) unwrappedMessage,
					incomingConnectionNumber);
		} else if (unwrappedMessage instanceof EncryptedMessageWrapperMessage) {
			handleEncryptedMessageWrapperMessage(
					(EncryptedMessageWrapperMessage) unwrappedMessage,
					incomingConnectionNumber);
		} else if (unwrappedMessage instanceof ServerAnnounceMessage) {
			handleServerAnnounceMessage((ServerAnnounceMessage) unwrappedMessage);
		} else if (unwrappedMessage instanceof AESkeyMessage) {
			try {
				handleAESkeyMessage((AESkeyMessage) unwrappedMessage,
						ServerConnectionIdManager.getFullDown(connection,
								incomingConnectionNumber));
			} catch (NoSuchAlgorithmException e) {
				Print.error("Bad Algorithm for AES key handling", e);
			} catch (NoSuchPaddingException e) {
				Print.error("Bad Padding for AES key handling", e);
			}
		} else {
			Print.error("Unknown message received from:"
					+ connection.getRemoteEnd());
		}
	}

	private void handleClientRequest(ClientRequest unwrappedMessage,
			Long incomingConnectionNumber) {
		if (unwrappedMessage instanceof UserLoginRequest) {
			// if login is for this server
			if (((UserLoginRequest) unwrappedMessage).getPublicationServer()
					.equalsIgnoreCase(server.getServerName())) {
				handleUserLoginRequest((UserLoginRequest) unwrappedMessage,
						incomingConnectionNumber);
			} else {
				handlePublishedUserMessage((UserLoginRequest) unwrappedMessage);
			}
		} else if (unwrappedMessage instanceof UserLogoutRequest) {
			handleUserLogoutRequest((UserLogoutRequest) unwrappedMessage);
		} else if (unwrappedMessage instanceof KeyRequest) {
			handleKeyRequest((KeyRequest) unwrappedMessage);
		}
	}

	/**
	 * Handles a message that is going downstream (server --> client)
	 * 
	 * @param encMsg
	 *            an encryptedMessage that is heading downstream
	 * @param incomingConnectionNumber
	 *            the connection number of the outer message
	 */
	private void handleDownstreamMessage(EncryptedMessageWrapperMessage encMsg,
			Long incomingConnectionNumber) {
		// If message is encrypted wrapper going
		// publication server -> client (downstream)
		String fulldown = Server.connectionIdManager
				.getFullDown(incomingConnectionNumber);
		Long downId = Server.connectionIdManager.getDownStreamId(fulldown);

		// get the out connection to the next server
		Connection outConn = Server.connectionIdManager.getOutChannel(fulldown);

		Cipher enc;
		try {
			enc = Cipher.getInstance(Constants.SYMMETRIC_CIPHER_TYPE);

			enc.init(Cipher.ENCRYPT_MODE,
					Server.connectionIdManager.getAESKeyForRoute(fulldown));
			encMsg = new EncryptedMessageWrapperMessage(enc, encMsg,
					Constants.SYMMETRIC_ENCRYPTION);
		} catch (Exception e) {
			Print.error(
					"Unable to add encryption layer sending message server->client",
					e, server);
		}
		encMsg.setDirection(Constants.DOWNSTREAM);
		encMsg.setDownId(downId);
		encMsg.setConnectionNumber(downId);

		Print.debug("Passing a message downstream to a user.");
		try {
			outConn.sendMessage(encMsg);
		} catch (Exception e) {
			Print.error("Failed to write object when passing downstream", e);
		}
		return;
	}

	/**
	 * handles a message that is going upstream (client --> server)
	 * 
	 * @param encMsg
	 *            the encrypted message that is going upstream
	 * @param incomingConnectionNumber
	 *            the connection number from the outer layer
	 */
	private void handleUpstreamMessage(EncryptedMessageWrapperMessage encMsg,
			long incomingConnectionNumber) {

		// the ip/port of and the connection number provided by
		// the remote downstream system
		String fulldown = null;

		// if the message is for a persistent route
		if (encMsg.usePersistentRoute()) {
			fulldown = ServerConnectionIdManager.getFullDown(connection,
					incomingConnectionNumber);

			// lookup a pseudo-id to forward to a remote upstream system
			Long upId = Server.connectionIdManager.getUpstreamId(fulldown);

			if (upId == null) {
				// there isn't a connection number associated
				// with it yet, so create a new number for the route
				upId = Server.generateConnectionId();
				Server.connectionIdManager.add(incomingConnectionNumber, upId,
						connection);

			}
			// out bound connection number is sent as the pseudo
			// id for the user at this server
			outboundConnectionNumber = upId;
		} else {
			outboundConnectionNumber = (long) 0;
		}

	}

	/**
	 * Sends a message back to the user who requests a key
	 * 
	 * @param pkReq
	 */
	private void handleKeyRequest(KeyRequest pkReq) {
		Print.debug("A locally published user is asking for the keys for "
				+ pkReq.getTargetUserName());
		KeyResponse pkResp = null;
		// check if the user is currently online and send their keys
		if (Server.userNameToIdMap.containsKey(pkReq.getTargetUserName())
				&& Server.userNameToIdMap.get(pkReq.getTargetUserName()) != null) {
			PublicIdentity targetUserIdentity = Server.userNameToIdMap
					.get(pkReq.getTargetUserName());
			pkResp = new KeyResponse(targetUserIdentity);
			Print.debug("*******************************");
			Print.debug(targetUserIdentity.getPublicRSAKey().toString());
			Print.debug("*******************************");
		} else {
			// Target user not found online, send an empty response
			Print.debug("User is not online: " + pkReq.getTargetUserName());
			pkResp = new KeyResponse(pkReq.getTargetUserName());
		}
		try {
			sendMessageToPublishedUser(pkResp, pkReq.getSrcUserName());
			Print.debug("Key Response is sent back. Target UserName is "
					+ pkReq.getTargetUserName());
		} catch (SendMessageToPublishedUserException e) {
			Print.error("Could not send message to published user", e);
		}

	}

	/**
	 * A message sharing an AES key
	 * 
	 * @param unwrappedMessage
	 *            an AES key message
	 * @throws NoSuchAlgorithmException
	 * @throws NoSuchPaddingException
	 */
	private void handleAESkeyMessage(AESkeyMessage unwrappedMessage,
			String fulldown) throws NoSuchAlgorithmException,
			NoSuchPaddingException {
		Print.received(serverName, "recieved AES KEY");

		Server.connectionIdManager.setAESKeyForRoute(fulldown,
				unwrappedMessage.sharedAESKey);
	}

	/**
	 * A server creating a new connection
	 * 
	 * @param unwrappedMessage
	 */
	private void handleServerAnnounceMessage(
			ServerAnnounceMessage unwrappedMessage) {
		Print.received(serverName, "received SERVER ANNOUNCE MESSAGE from "
				+ unwrappedMessage.getName());
	}

	/**
	 * Handles messages that are encrypted and still need to be forwarded
	 * 
	 * @param unwrappedMessage
	 *            The message that has already been decrypted at this server
	 */
	private void handleEncryptedMessageWrapperMessage(
			EncryptedMessageWrapperMessage unwrappedMessage,
			Long incomingConnectionNumber) {

		Print.received(serverName, "ENCRYPTED WRAPPER MESSAGE");

		try {
			Connection o = getConnection(unwrappedMessage.getNextHop());
			unwrappedMessage.setConnectionNumber(this.outboundConnectionNumber);
			o.sendMessage(unwrappedMessage);
		} catch (Exception e) {
			Print.error("Error occured while forwarding encrypted message to: "
					+ unwrappedMessage.getNextHop(), e);
		}
	}

	/**
	 * 
	 * @param destinationName
	 *            the destination with which to make a connection
	 * @return OutConnection an out-bound connection to the destination
	 * @throws ServerToServerConnectionException
	 */
	private Connection getConnection(String destinationName)
			throws ServerToServerConnectionException {
		Connection newCon = null;
		if (!Server.currentConnections.containsKey(destinationName)) {
			try {

				ServerEntry dest = availableServers.getByName(destinationName);
				// create new server to server connection
				newCon = new Connection(dest, this.serverName);
				// Socket s2sSocket = new Socket( dest.serverIP, dest.serverPort
				// );
				ServerAnnounceMessage me = new ServerAnnounceMessage(serverName);
				// con = new OutConnection( s2sSocket, dest.serverName );
				// ObjectOutputStream out2 = con.getOutputStream();

				// Write announce message to server

				try {
					EncryptedMessageWrapperMessage enc = new EncryptedMessageWrapperMessage(
							availableServers.getByName(destinationName)
									.getAsymmetricCipherEngine(), me,
							Constants.ASYMMETRIC_ENCRYPTION);
					Print.sending(serverName,
							"Sending wrapped server announce message");
					Print.debug((Message) enc);
					newCon.getOutputStream().writeObject(enc);
					newCon.getOutputStream().flush();
					// out2.writeObject( enc );
					// out2.flush();
				} catch (IOException e1) {
					throw new Exception(
							"Error encountered while sending announce message to server: "
									+ destinationName);
				}

				Server.currentConnections.put(dest.serverName, newCon);

				// Create a way to handle objects coming in on the new
				// connection
				InConnectionThread newInConnection = new InConnectionThread(
						newCon, server);
				newInConnection.start();
			} catch (Exception e) {
				Print.error("Unable to create new connection to server "
						+ destinationName, e);
				throw new ServerToServerConnectionException(
						"Unable to connect to server " + destinationName);
			}
		} // end creation of new connection
			// verify the connection to the destination was created
		if (Server.currentConnections.containsKey(destinationName))
			return Server.currentConnections.get(destinationName);
		else
			throw new ServerToServerConnectionException(
					"A connection could not be created to " + destinationName);
	}

	/**
	 * 
	 * @param message
	 *            a chat message
	 */
	private void handleRSAEncryptedMessage(RSAEncryptedMessage message) {
		// check if destination is local or another server
		Print.received(serverName, "received RSAEncryptedMessage");

		// extract the destination user
		String dest = null;
		if (message.getDstUserName() != null) {
			dest = message.getDstUserName();

		} else {
			Print.error("Error in InConnectionThread: unable to handle text message because destination user is null.");
		}

		try {
			String hostingServer = Server.usernameToHostingServer.get(dest);
			if (hostingServer == null)
				throw new Exception("Unable to find user's publication server.");
			if (hostingServer.compareTo(this.serverName) == 0)
			// Recipient also published here
			{
				// Connections contains links from username/unique server number
				// to tcp connection

				Print.sending(serverName,
						"Send the message to the destination socket");
				sendMessageToPublishedUser(message, dest);

			} else {
				// We need to send the message to the server which is
				// "hosting" the chatroom or user name
				Print.debug("The user '" + dest
						+ "' isn't published here, they are at "
						+ hostingServer);
				getConnection(hostingServer).sendMessage(message);
			}

		} catch (Exception e) {
			Print.error(
					"Error handling text message: " + e.getLocalizedMessage(),
					e);
		}
	}

	/**
	 * send a message to a user published at this publication server
	 * 
	 * @param message
	 *            the message to send to a locally published user
	 * @param dstUser
	 *            the user to send the message to
	 * @throws SendMessageToPublishedUserException
	 */
	private void sendMessageToPublishedUser(Message message, String dstUser)
			throws SendMessageToPublishedUserException {
		Print.debug("Sending a message to a locally published user");
		String fullreturnId = Server.getConnectionIdFor(dstUser);
		Long returnId = Server.connectionIdManager
				.getDownStreamId(fullreturnId);

		Cipher enc;
		try {
			enc = Cipher.getInstance(Constants.SYMMETRIC_CIPHER_TYPE);

			enc.init(Cipher.ENCRYPT_MODE,
					Server.connectionIdManager.getAESKeyForRoute(fullreturnId));
			EncryptedMessageWrapperMessage encMsg = new EncryptedMessageWrapperMessage(
					enc, message, Constants.SYMMETRIC_ENCRYPTION);
			encMsg.setDirection(Constants.DOWNSTREAM);
			encMsg.setDownId(returnId);
			encMsg.setConnectionNumber(returnId);

			Connection temp = Server.connectionIdManager
					.getOutChannel(fullreturnId);
			Print.sending("This server", encMsg.getAllContents());
			temp.sendMessage(encMsg); // was message
		} catch (NoSuchAlgorithmException e) {
			throw (new SendMessageToPublishedUserException(
					"NoSuchAlgorithmException"));
		} catch (NoSuchPaddingException e) {
			throw (new SendMessageToPublishedUserException(
					"NoSuchPaddingException"));
		} catch (InvalidKeyException e) {
			throw (new SendMessageToPublishedUserException(
					"InvalidKeyException"));
		} catch (IOException e) {
			throw (new SendMessageToPublishedUserException("IOException"));
		} catch (IllegalBlockSizeException e) {
			throw (new SendMessageToPublishedUserException(
					"IllegalBlockSizeException"));
		} catch (BadPaddingException e) {
			throw (new SendMessageToPublishedUserException(
					"BadPaddingException"));
		}
	}

	/**
	 * Send a message from this server to another
	 * 
	 * @param msg
	 * @param destination
	 */
	public void serverSendMessage(Serializable msg, String destination) {
		try {
			EncryptedMessageWrapperMessage enc = new EncryptedMessageWrapperMessage(
					availableServers.getByName(destination)
							.getAsymmetricCipherEngine(), msg,
					Constants.ASYMMETRIC_ENCRYPTION);
			// Set number expected by the other server
			// enc.setConnectionNumber( this.outboundConnectionNumber );
			getConnection(destination).sendMessage(enc);
		} catch (Exception e) {
			Print.error(
					"Error encountered when sending server->server message "
							+ serverName + " -> " + destination, e);
		}
	}

	/**
	 * Log user into system, and broadcast to all other servers where user is
	 * hosted (Of course only after verifying the message)
	 * 
	 * @param login
	 */
	private void handleUserLoginRequest(UserLoginRequest login, Long returnId) {
		Print.received(serverName, "USER LOGIN REQUEST MESSAGE");

		PublicIdentity pubId = login.getPubIdent();
		String uid = PublicIdentity.composeUserName(pubId.getPublicRSAKey(),
				pubId.getVerKey());
		if (this.verifyIntegrityOfLogin(login)) {
			// UserLoginAnnounce msg = new UserLoginAnnounce( login );

			// Add user keys to mapping of their name
			Long time = login.getAnnounceTime();
			Server.userNameToIdMap.put(uid, pubId);
			Server.locallyPublishedUsers.add(uid);
			Server.usernameToHostingServer.put(login.getSrcUserName(),
					login.getPublicationServer());
			Print.debug("User added to locally published list: " + uid);
			String fullretId = null;

			fullretId = ServerConnectionIdManager.getFullDown(connection,
					returnId);

			Server.addUserConnectionId(uid, fullretId);

			Long latest = Server.userNameToLastHeardFrom.get(uid);
			if (latest != null) {
				if (time > latest)
					Server.userNameToLastHeardFrom.put(uid, time);
			} else
				Server.userNameToLastHeardFrom.put(uid, time);

			// Broadcast change to everyone
			for (ServerEntry server : availableServers.servers) {
				if (!server.serverName.equalsIgnoreCase(this.server
						.getServerName())) {
					try {
						serverSendMessage(login, server.serverName);
					} catch (Exception e) {
						Print.error("Cannot send to " + server.serverName, e);
					}
				}
			}
		} else {
			Print.error("Failed login attempt");
		}

	}

	/**
	 * Log user out of system, remove from list, and broadcast to all servers
	 * (Of course only if the message is verified)
	 * 
	 * @param unwrappedMessage
	 */
	private void handleUserLogoutRequest(UserLogoutRequest unwrappedMessage) {
		Print.received(serverName, "USER LOGOUT REQUEST MESSAGE");
		// remove user from list
		Server.userNameToIdMap.remove(unwrappedMessage.getSrcUserName());

		if (Server.locallyPublishedUsers.remove(unwrappedMessage
				.getSrcUserName())) {
			for (ServerEntry server : availableServers.servers) {
				try {
					serverSendMessage(unwrappedMessage, server.serverName);
				} catch (Exception e) {
					Print.error("Could not send message to "
							+ server.serverName, e);
				}
			}
		} else {
			Server.usernameToHostingServer.remove(unwrappedMessage
					.getSrcUserName());
		}
	}

	/**
	 * Verify that the message was signed by the user that logged in and update
	 * list
	 * 
	 * @param publish
	 *            the message generated by the user
	 */
	private void handlePublishedUserMessage(UserLoginRequest req) {
		Print.received(serverName,
				"Publish notice for user " + req.getSrcUserName() + " at "
						+ req.getPublicationServer());
		if (this.verifyIntegrityOfLogin(req)) {
			Server.usernameToHostingServer.put(req.getSrcUserName(),
					req.getPublicationServer());
			Server.userNameToIdMap.put(req.getSrcUserName(), req.getPubIdent());
		} else {
			Print.error("Failed to verify published user message");
		}
	}

	/**
	 * Verify the user id and the signature are correct for the login message
	 * 
	 * @param login
	 *            message to verify
	 * @return true if the message is verified false if the message is not.
	 */
	private boolean verifyIntegrityOfLogin(UserLoginRequest login) {
		boolean verified = false;
		PublicIdentity pubId = login.getPubIdent();
		String uid = PublicIdentity.composeUserName(pubId.getPublicRSAKey(),
				pubId.getVerKey());
		if (login.getSrcUserName().equals(uid)) {
			if (SecurityUtils.ver(login.getSignedComponent(), login.getSig(),
					pubId.getVerKey())) {
				verified = true;
			} else {
				// Verification failed for the login request, because the
				// signature is incorrect.
				LocalFileOps
						.appendAuditFile("Verification failed for user login.");
				Print.error("@Server: " + this.serverName
						+ " Verification failed for login of user '" + uid
						+ "', the signature could not be verified.");
			}
		} else {
			// Failed to login because the user name trying to login didn't
			// match the one computed
			Print.error("@Server: " + this.serverName
					+ " Verification failed for login of user '" + uid
					+ "', because the keys provided don't belong to that user.");
			Print.error("Verification failed for user login.");
		}
		return verified;
	}

}
